App Initialization

AppInitializer in toolkit:initializer coordinates app startup and exposes its current state. It is an AppScope singleton exposed by AppComponent.appInitializer. The shared app maps this state to a running screen, a failure screen with retry, or navigation content.

Execution Order

Each accepted initialize() command runs two phases:

  1. Core Initializer implementations run sequentially on AppDispatchers.main, in ascending integer-key order.
  2. If every core initializer succeeds, AsyncInitializer implementations run sequentially on AppDispatchers.default, also in ascending integer-key order.

Both phases must finish successfully before the state becomes Succeeded. Async initializers run on a background dispatcher, but still participate in startup and delay navigation until they finish. Keep blocking work off the main dispatcher.

The current app registers:

PhaseKeyInitializerPurpose
Core0AppConfigInitializerInitialize app configuration
Core1LoggerInitializerConfigure logging
Async0StartRouteInitializerResolve GetStartRoute and assign AppNavRoutes.Default

Register an Initializer

Implement Initializer for setup on the main dispatcher, or AsyncInitializer for setup on the default dispatcher. Both define suspend fun init() and can receive dependencies through Metro.

For example, the existing start-route initializer propagates a failed route lookup into the pipeline:

kotlin
1import dev.zacsweers.metro.Inject
2import io.baselines.sample.ui.navigation.AppNavRoutes
3import io.baselines.sample.ui.navigation.GetStartRoute
4import io.baselines.toolkit.initializer.AsyncInitializer
5
6@Inject
7class StartRouteInitializer(
8 private val getStartRoute: GetStartRoute,
9) : AsyncInitializer {
10
11 override suspend fun init() {
12 AppNavRoutes.Default = getStartRoute(Unit).getOrThrow()
13 }
14}

Contribute the implementation to the appropriate map in the existing InitializersProvider in app:multiplatform. Its start-route binding is:

kotlin
1@Binds
2@IntoMap
3@IntKey(0)
4val StartRouteInitializer.bind: AsyncInitializer

The binding annotations come from dev.zacsweers.metro. InitializersProvider uses @ContributesTo(AppScope::class) and declares both maps with @Multibinds(allowEmpty = true): Map<Int, () -> Initializer> and Map<Int, () -> AsyncInitializer>.

Choose an unused key within the target map; core and async maps may use the same keys. Lower keys run first within their phase. Preserve the existing bindings when adding another initializer. For a new module, follow Create New Module, then add it to commonMain.dependencies in app/multiplatform/build.gradle.kts.

Starting and Observing Initialization

Android starts initialization in App.onCreate(); iOS starts it in the AppDelegate launch callback:

kotlin
1appComponent.appInitializer.initialize()

initialize() returns without waiting for startup to finish and is safe to call from any thread. The attempt runs in an app-owned coroutine scope, so cancelling the calling coroutine does not cancel it.

state: StateFlow<AppInitState> retains the latest state for existing and late subscribers:

StateMeaning
RunningStartup is pending, or an attempt is scheduled or running
SucceededAll core and async initializers completed successfully
Failed(cause)The attempt failed or was cancelled

The initial value is Running, even before the first command. Collecting the flow does not start initialization. Collectors remain subscribed across retries; slow collectors receive the latest state.

Failures and Retries

  • A non-cancellation exception thrown by init() is collected while the remaining initializers in that phase run. Their failures are attached as suppressed exceptions to AppInitializerException.
  • Any core-phase failure prevents the async phase from starting.
  • An exception while obtaining an initializer from its provider stops the attempt and is exposed as the failure cause.
  • Cancellation stops the attempt, publishes Failed(cause), and releases the guard for a later retry. Initializers must propagate CancellationException from broad exception handlers.
  • Calls while an attempt is scheduled or running are ignored without queuing or restarting work.
  • A call after completion starts a new attempt, including after success. It publishes Running and reruns the full pipeline; it does not resume from the failed initializer.

Make initializer side effects safe to repeat. Earlier successful setup may already have taken effect when a later initializer fails.

Running, Failure, and Navigation Screens

MainViewModel.state() collects the initializer state with collectAsStateWithLifecycle(). ComposeApp renders the resulting MainUiState.ContentUm:

Initialization stateShared UI
RunningAppInitRunningScreen, with a centered progress indicator
FailedAppInitFailureScreen, with a retry action
Succeeded and a non-empty back stackNavigation content through NavDisplay
Succeeded and an empty back stackNo content while the start route initializes the stack

Both startup screens live in app/multiplatform/.../ui and render outside navigation. Customize them there; they need no route, navigation entry, or separate feature module. The failure screen dispatches MainUiEvent.RetryInit, which MainViewModel handles by calling AppInitializer.initialize().

The start route is supplied to the navigator only after Succeeded. A restored stack can exist earlier, but startup state still determines whether it is shown. See the Navigation Guide for restoration behavior.

Platform Launch Screens

While the app initializes:

  • Android shows the system splash screen.
  • iOS shows LaunchScreen.

On success, the app shows navigation content. On failure, it shows AppInitFailureScreen.

When the user taps retry, both platforms show AppInitRunningScreen while initialization runs again.